from IPython.display import Math
In this lab, we will use high- and low-pass filters to separate audio frequencies. Circuits like these are commonly used in high-end audio equipment for playing different signals coming from one main signal onto multiple speakers which are tailored to a particular range.

1. Analytic transfer functionΒΆ
We will analytically derive the transfer function $H(S)$ for each circuit. We will be using the sympy python module to handle algebra.
import sympy as sp # import the module
S, t = sp.symbols("S t") # create the S and t symbols that we will manipulate
# define helper functions for circuit analysis
def series(*Z):
return sum(Z)
def parallel(*Z):
return 1 / sum(1/z for z in Z)
We first implement our components:
L = sp.Rational(1,1_000) # 1 mH
R = 8 # 8 Ohm
C_sub = sp.Rational(50,1_000_000) # 50 uF
C_tweet = sp.Rational(5, 1_000_000) # 5 uF
We can convert these into S-domain:
L = L*S
R = R
C_sub = 1/(C_sub*S)
C_tweet = 1/(C_tweet*S)
Now we can construct our S-domain transfer function:
# subwoofer
H_lp = (parallel(R, C_sub) / series(L, parallel(R, C_sub))).simplify()
display(Math("H_{lp} = " + sp.latex(H_lp)))
# tweeter
H_hp = (parallel(R, L) / series(C_tweet, parallel(R, L))).simplify()
display(Math("H_{hp} = " + sp.latex(H_hp)))
Finally, we can take the inverse laplace transform to get the impulse function:
h_lp = sp.inverse_laplace_transform(H_lp, S, t)
display(Math("h_{lp} = " + sp.latex(h_lp)))
h_hp = sp.inverse_laplace_transform(H_hp, S, t)
display(Math("h_{hp} = " + sp.latex(h_hp)))
2. Python SimulationΒΆ
Next, we wil use the scipy.signal python module to generate a Bode plot of the frequency response for each filter.
import numpy as np
from matplotlib import pyplot as plt
from scipy import signal
from math import factorial
def bode(H, w):
N_s = np.array(sp.Poly(sp.numer(H), S).all_coeffs()).astype(float)
D_s = np.array(sp.Poly(sp.denom(H), S).all_coeffs()).astype(float)
sys = signal.TransferFunction(N_s, D_s)
w, mag, phase = signal.bode(sys, w)
return mag, phase
f = np.geomspace(100, 35_000, 100)
w = f*2*np.pi
mag_lp, phase_lp = bode(H_lp, w)
mag_hp, phase_hp = bode(H_hp, w)
fig, axs = plt.subplots(2)
axs[0].plot(f, mag_lp, color="green", label="subwoofer")
axs[0].plot(f, mag_hp, color="blue", label="tweeter")
axs[0].semilogx()
axs[0].set_title("Magnitude of filter transfer function")
axs[0].set_xlabel("Frequency (Hz)")
axs[0].set_ylabel("Gain (dB)")
axs[0].legend(loc='center left', bbox_to_anchor=(1, 0.5))
axs[1].plot(f, phase_lp, color="green", label="subwoofer")
axs[1].plot(f, phase_hp, color="blue", label="tweeter")
axs[1].semilogx()
axs[1].set_title("Phase of filter transfer function")
axs[1].set_xlabel("Frequency (Hz)")
axs[1].set_ylabel("Phase (degrees)")
axs[1].legend(loc='center left', bbox_to_anchor=(1, 0.5))
plt.suptitle("Figure: Bode plot for Subwoofer and Tweeter Networks")
fig.align_titles()
fig.tight_layout()
plt.show()
We can also use these plots to find the cutoff point of our filters:
fc_lp = f[abs(mag_lp - (- 3)).argmin()]
print(f"Cutoff point for subwoofer is {fc_lp} Hz")
fc_hp = f[abs(mag_hp - (- 3)).argmin()]
print(f"Cutoff point for tweeter is {fc_hp} Hz")
Cutoff point for subwoofer is 1066.3659778001622 Hz Cutoff point for tweeter is 2915.8585332101434 Hz
Now, we reuse code from the previous lab to extract the impulse response of each filter:
# this is my code from last time
def inverse_laplace_lab8( N_s , D_s , t ) :
pfe = dict()
K, R, P = signal.residue(N_s, D_s)
for k, r in zip(K, R):
if r not in pfe:
pfe[r] = list()
pfe[r].append((k.conjugate() if r.imag < 0 else k) / factorial(len(pfe[r])))
f = np.zeros_like(t)
for r in pfe:
for n, k in enumerate(pfe[r]):
f += (t**n)*np.abs(k)*np.exp(r.real*t)*np.cos(abs(r.imag)*t+np.angle(k))
for n, p in enumerate(P[::-1]):
f += p * t**n
return f
# this is a wrapper which can accept sympy expressions as the transfer function input
def inverse_laplace(H, t):
N_s = sp.Poly(sp.numer(H), S).all_coeffs()
D_s = sp.Poly(sp.denom(H), S).all_coeffs()
return inverse_laplace_lab8(N_s, D_s, t)
And we plot each response:
duration = .004
sample_rate = 44_100
t = np.linspace(0, duration, int(duration*sample_rate))
h_lp = inverse_laplace(H_lp, t)
h_hp = inverse_laplace(H_hp, t)
plt.plot(t*1000, h_lp, color="green", label="subwoofer")
plt.plot(t*1000, h_hp, color="blue", label="tweeter")
plt.title("Impluse response of filters")
plt.xlabel("t (ms)")
plt.ylabel("Volts")
plt.legend()
plt.show()
In addition to plotting the inpuslse response, we can digitally convolve it with an input signal (Handel's Messiah, for example) to predict what it will sound like when played over the filtered speaker:
import sounddevice as sd
import soundfile as sf
from IPython.display import Audio
filename = "handel.wav"
data, fs = sf.read(filename, dtype = 'float32') # read the file
data = data[:, 0] # convert to mono
data_lp = np.convolve(data, h_lp ) # apply low - pass filtering
data_lp /= max(abs(data_lp)) # normalize, preserve hearing
sf.write("lp.wav", data_lp, 44_100) # write to .wav file
data_hp = np.convolve(data, h_hp) # apply high - pass filtering
data_hp /= max ( abs ( data_hp ) ) # normalize, preserve hearing
sf.write("hp.wav", data_hp, 44_100) # write to .wav file
Original AudioΒΆ
Subwoofer AudioΒΆ
Tweeter AudioΒΆ
Note: PDFs don't let me embed the audio, so check out the html version at https://nnnnnnnn.info/hw/ece2260/lab09/09.html to hear what these sound like!
To me, the low-pass filtered version sounds like what it would sound like if this were being performed live, but in another room. Low sounds are known to travel through walls better so it makes sense that playing music through a wall is an acoustic version of a low-pass filter. Specifically, the violins are quieter than they are in the original.
To me, the high-pass filtered version was a bit more difficult to hear the difference. This may be because the cutoff point is so much higher, so after normalization the sounds within the pass region take up less bandwidth than most of the sounds. However listening on headphones, I can more distinctly tell a difference. To me, this version sounds more "tinny", like hearing a recording of a recording. The low strings are harder to hear, but it's less obvious than missing the violins in the low-pass filtered version.
3. LTSpiceΒΆ
We implemented our circuits in LTSpice, then did an AC sweep from $100Hz$ to $35kHz$ to generate a Bode plot for the circuits.
Figure: our filters, modeled in LTSpice.
Figure: the Bode plots for our filters, generated by LTSpice. The dotted lines refer to the phase and use the right y-axis.
The predictions made by LTSpice visibly match the predictions made by our python script
4. HardwareΒΆ
We first connected the speaker directly to the function generator to test its range and our range. This speaker is known to attenuate frequencies below about $600Hz$, and our hearing also isn't perfect at all frequencies. We generated a list of frequencies that should include the maximum and cutoff frequencies of each filter, as well as 20 logarithmically spaced frequencies between $100Hz$ and $35kHz$.
F = [fc_lp, fc_hp, f[mag_lp.argmax()], f[mag_hp.argmax()]]
F.extend(np.geomspace(100, 35_000, 20).astype(int))
F = sorted(map(int, set(F)))
print(F)
[100, 136, 185, 252, 343, 467, 635, 664, 865, 1066, 1178, 1603, 2182, 2915, 2970, 4043, 5503, 7491, 10197, 13879, 18891, 25714, 35000]
Without any filtering, the range of frequencies for which I could hear speaker output was $[467Hz, 1603Hz ]$
With the lowpass-filter, the range of frequencies for which I could hear speaker output was $[467Hz, 1066Hz ]$
With the highpass-filter, the range of frequencies for which I could hear speaker output was $[467Hz, 1603Hz ]$
The fact that the lowest audible frequency was the same with or without the highpass-filter suggests that the speaker's internal low-frequency attenuation was stronger than anything our filter circuits were generating.
When we played Messiah over the speakers, the lowpass-filtered version sounded much like it did over the digital filter; like we were hearing the sound muted through a wall. It was difficult to hear a difference between the original and highpass-filtered versions, which is probably due to the internal highpass filter of the speaker. If I were to redo the lab, I could have acquired a second headphone adapter and connected headphones in parallel with enough resistance to make it equivalent to the $ 8 \Omega $ of the speaker, then listened to it over headphones, to see if that makes the difference more audible, as it did with the digital filters.
We then replaced the speaker with $8 \Omega$ of resistance to measure the filter response.
#Subwoofer Measurements
f_mse, vout_lp, phase_mse_lp, vin_lp = np.array([
[100,.271,-6.7,.330],
[136,.267,-9,.320],
[185,.259,-12,.360],
[252,.243,-17,.285],
[343,.223,-24,.249],
[467,.200,-37,.209],
[635,.167,-59,.171],
[642,.167,-60,.169],
[865,.135,-92,.167],
[1047,.113,-113,.193],
[1178,.105,-120,.221],
[1603,.080,-138,.322],
[2182,.060,-145,.462],
[2970,.046,-150,.640],
[3124,.044,-150,.680],
[4043,.035,-147,.850],
[5503,.027,-147,1.09],
[7491,.020,-145,1.33],
[10197,.016,-152,1.54],
[13879,.011,-153,1.75],
[15915,.010,-150,1.81],
[18891,.010,-150,1.8],
[25714,.007,-150,1.95],
[35000,.008,-150, 1.98]]).T
#Tweeter measurements
f_mse, vout_hp, phase_mse_hp, vin_hp = np.array([
[100,.0142,104,2.01],
[136,.018,106,2.01],
[185,.024,111,1.97],
[252,.032,117,1.89],
[343,.047,123,1.79],
[467,.067,126,1.63],
[635,.096,125,1.37],
[642,.098,127,1.36],
[865,.132,123,1.11],
[1047,.154,118,.95],
[1178,.175,115,.860],
[1603,.207,101,.660],
[2182,.235,86,.510],
[2970,.251,67,.430],
[3124,.255,64,.410],
[4043,.263,51,.378],
[5503,.271,28,.358],
[7491,.275,29,.350],
[10197,.275,21,.342],
[13879,.279,16,.338],
[15915,.279,13,.334],
[18891,.279,12,.334],
[25714,.279,10,.330],
[35000,.279,6.5,.326]
]).T
db_lp = 20*np.log10(vout_lp/vin_lp)
db_hp = 20*np.log10(vout_hp/vin_hp)
fig, axs = plt.subplots(2)
axs[0].plot(f, mag_lp, color="green", label="subwoofer (predicted)")
axs[0].scatter(f_mse, db_lp, color="green", marker="x", label="subwoofer (measured)")
axs[0].plot(f, mag_hp, color="blue", label="tweeter (predicted)")
axs[0].scatter(f_mse, db_hp, color="blue", marker="x", label="tweeter (measured)")
axs[0].semilogx()
axs[0].set_title("Magnitude of filter transfer function")
axs[0].set_xlabel("Frequency (Hz)")
axs[0].set_ylabel("Gain (dB)")
axs[0].legend(loc='center left', bbox_to_anchor=(1, 0.5))
axs[1].plot(f, phase_lp, color="green", label="subwoofer (predicted)")
axs[1].scatter(
f_mse, phase_mse_lp, color="green", marker="x", label="subwoofer (measured)")
axs[1].plot(f, phase_hp, color="blue", label="tweeter (predicted)")
axs[1].scatter(
f_mse, phase_mse_hp, color="blue", marker="x", label="tweeter (measured)")
axs[1].semilogx()
axs[1].set_title("Phase of filter transfer function")
axs[1].set_xlabel("Frequency (Hz)")
axs[1].set_ylabel("Phase (degrees)")
axs[1].legend(loc='center left', bbox_to_anchor=(1, 0.5))
plt.suptitle("Figure: Bode plot for Subwoofer and Tweeter Networks")
fig.align_titles()
fig.tight_layout()
plt.show()
The filters do not quite reach the expected level of attenuation outside of the pass region, and they do not quite reach the full phase distortion, but they do both quite nearly match the predictions.
ConclusionΒΆ
In conclusion, we used three separate approaches to modeling filters: analytically modeling them with the help of python, using a simulator with the help of LTSpice, and actually measuring the physical hardware. Through this lab, we also gained intuition about the characteristics and practical result of filters, and even got to try using a digital filter.